File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/content-gates.php
<?php
// includes/content-gates.php
//
// Whole-post/page content-access sync (gate types: content_immediate, content_preview).
//
// Smallest possible sync against GET /loadContentAccessForWPSync (same partner/domain/token
// contract as the other WP sync endpoints). The response is a COMPLETE authoritative
// snapshot: a successful sync atomically replaces the stored rules (absent items become
// public; an empty snapshot empties the rules), while ANY failure preserves the previous
// snapshot untouched. recipe_card gating is a different system (recipe sync +
// membership-gate.php) and is ignored here on purpose.
//
// Storage: ONE non-autoloaded option - no custom tables, no post meta, and nothing about the
// articles themselves (no titles/bodies/categories/remote ids), only url_key -> access rule.
if (!defined('ABSPATH')) exit;
const ALLSPICE_CONTENT_GATES_OPTION = 'allspice_content_gates_v1';
function allspice_content_access_endpoint(): string {
$path = '/loadContentAccessForWPSync';
return apply_filters('allspice_content_access_endpoint', allspice_webhook_base() . $path);
}
/*
* Lookup keys must match on both sides. Remote url_keys arrive host-first without a scheme
* ("example.com/member-article/"); local keys come from get_permalink(). Both funnel through
* allspice_url_key() (the recipe/page normalizer: strips scheme, www., trailing slashes,
* index.html) so one rule produces one representation.
*/
function allspice_content_gate_key($raw): string {
$raw = trim((string)$raw);
if ($raw === '') return '';
if (strpos($raw, '://') === false) $raw = 'https://' . $raw;
return allspice_url_key($raw);
}
/*
* Normalize the snapshot items to url_key => ['gate_type' => ..., 'product_ids' => [...]].
* Rules: url_key required; gated entries must be dictionaries; only content_immediate /
* content_preview count (recipe_card and anything else ignored); product_id required,
* trimmed, deduplicated; multiple ids mean OR access. Mixed types on one URL: presentation
* uses content_immediate (it strictly dominates a preview), ALL valid ids stay as OR access,
* and a debug warning is emitted - the Portal normally sends one type per URL.
*/
function allspice_content_gates_normalize($items): array {
$out = [];
if (!is_array($items)) return $out;
foreach ($items as $item) {
if (!is_array($item)) continue;
$key = allspice_content_gate_key($item['url_key'] ?? '');
if ($key === '') continue;
$types = [];
$ids = [];
$gated = isset($item['gated']) && is_array($item['gated']) ? $item['gated'] : [];
foreach ($gated as $g) {
if (!is_array($g)) continue;
$type = trim((string)($g['gate_type'] ?? ''));
if ($type !== 'content_immediate' && $type !== 'content_preview') continue;
$pid = trim((string)($g['product_id'] ?? ''));
if ($pid === '') continue;
$types[$type] = true;
$ids[$pid] = true;
}
if ($ids === [] || $types === []) continue;
if (count($types) > 1 && function_exists('allspice_console_log')) {
allspice_console_log('[Allspice] content gate WARNING: mixed gate types for one URL; content_immediate wins', [
'url_key' => $key,
'types' => array_keys($types),
]);
}
$out[$key] = [
'gate_type' => isset($types['content_immediate']) ? 'content_immediate' : 'content_preview',
'product_ids' => array_keys($ids),
];
}
ksort($out);
return $out;
}
/*
* Fetch + store. NEVER called on ordinary frontend page loads - only from the cron sync and
* the admin manual-sync flow. Failures of any kind preserve the existing snapshot.
*/
function allspice_content_gates_sync_failed(string $error): array {
if (function_exists('allspice_opt_update')) {
allspice_opt_update(['content_gates_last_error' => $error]);
}
return ['ok' => false, 'changed' => false, 'count' => 0, 'error' => $error];
}
function allspice_refresh_content_gates(bool $force = false): array {
$s = allspice_opt_get();
$partner_id = trim((string)$s['partner_id']);
$domain_id = trim((string)$s['domain_id']);
$token = trim((string)$s['webhook_token']);
if ($partner_id === '' || $domain_id === '' || $token === '') {
return allspice_content_gates_sync_failed('Missing partner_id, domain_id, or webhook_token');
}
$url = add_query_arg(['partner_id' => $partner_id, 'domain_id' => $domain_id], allspice_content_access_endpoint());
$headers = [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
'X-Allspice-Partner-Id' => $partner_id,
'X-Allspice-Domain-Id' => $domain_id,
'Cache-Control' => 'no-cache',
'Pragma' => 'no-cache',
];
$resp = wp_remote_get($url, ['timeout' => 15, 'headers' => $headers]);
if (is_wp_error($resp)) {
return allspice_content_gates_sync_failed(trim((string)$resp->get_error_message()) ?: 'WP_Error');
}
$code = (int)wp_remote_retrieve_response_code($resp);
$body = (string)wp_remote_retrieve_body($resp);
if ($code < 200 || $code >= 300) {
return allspice_content_gates_sync_failed('HTTP ' . $code . ': ' . substr($body, 0, 200));
}
$json = json_decode($body, true);
if (!is_array($json) || ($json['ok'] ?? null) === false) {
return allspice_content_gates_sync_failed('Invalid JSON or ok=false');
}
if ((int)($json['schema_version'] ?? 0) !== 1) {
return allspice_content_gates_sync_failed('Unsupported schema_version');
}
if (($json['full_snapshot'] ?? null) !== true) {
return allspice_content_gates_sync_failed('Not a full snapshot');
}
if (trim((string)($json['partner_id'] ?? '')) !== $partner_id) {
return allspice_content_gates_sync_failed('partner_id mismatch');
}
if (trim((string)($json['domain_id'] ?? '')) !== $domain_id) {
return allspice_content_gates_sync_failed('domain_id mismatch');
}
if (!array_key_exists('items', $json) || !is_array($json['items'])) {
return allspice_content_gates_sync_failed('Missing items array');
}
if ((int)($json['count'] ?? -1) !== count($json['items'])) {
return allspice_content_gates_sync_failed('count does not match items');
}
if (function_exists('allspice_opt_update')) {
allspice_opt_update(['content_gates_last_error' => '']);
}
$gates = allspice_content_gates_normalize($json['items']);
$hash = sha1((string)wp_json_encode($gates));
$existing = get_option(ALLSPICE_CONTENT_GATES_OPTION);
if (is_array($existing) && (string)($existing['hash'] ?? '') === $hash) {
/* Unchanged normalized snapshot: no write (item: same hash avoids the write). */
if (function_exists('allspice_opt_update')) {
allspice_opt_update(['content_gates_synced_at' => time(), 'content_gates_last_error' => '']);
}
return ['ok' => true, 'changed' => false, 'count' => count($gates), 'error' => ''];
}
/* Atomic authoritative replace: absent items become public; empty snapshot empties rules. */
if (function_exists('allspice_opt_update')) {
allspice_opt_update(['content_gates_synced_at' => time(), 'content_gates_last_error' => '']);
}
update_option(ALLSPICE_CONTENT_GATES_OPTION, [
'schema' => 1,
'gates' => $gates,
'hash' => $hash,
'response_hash' => (string)($json['hash'] ?? ''),
'synced_at' => time(),
], false /* never autoloaded */);
allspice_content_gates_reset_request_cache();
return ['ok' => true, 'changed' => true, 'count' => count($gates), 'error' => ''];
}
/* ------------------------------------------------------------------------------- lookups */
function allspice_content_gates_reset_request_cache(): void {
$GLOBALS['allspice_content_gates_cache'] = null;
$GLOBALS['allspice_content_gate_lookups'] = [];
}
/* Full normalized map, cached for the current PHP request only. */
function allspice_content_gates_get(): array {
if (isset($GLOBALS['allspice_content_gates_cache']) && is_array($GLOBALS['allspice_content_gates_cache'])) {
return $GLOBALS['allspice_content_gates_cache'];
}
$stored = get_option(ALLSPICE_CONTENT_GATES_OPTION);
$gates = (is_array($stored) && isset($stored['gates']) && is_array($stored['gates'])) ? $stored['gates'] : [];
$GLOBALS['allspice_content_gates_cache'] = $gates;
return $gates;
}
/* Rule for one url_key, or null. Per-key result cached for the request. */
function allspice_content_gate_for_url_key($url_key) {
$key = allspice_content_gate_key($url_key);
if ($key === '') return null;
if (isset($GLOBALS['allspice_content_gate_lookups']) && array_key_exists($key, $GLOBALS['allspice_content_gate_lookups'])) {
return $GLOBALS['allspice_content_gate_lookups'][$key];
}
$gates = allspice_content_gates_get();
$rule = isset($gates[$key]) && is_array($gates[$key]) ? $gates[$key] : null;
if (!isset($GLOBALS['allspice_content_gate_lookups']) || !is_array($GLOBALS['allspice_content_gate_lookups'])) {
$GLOBALS['allspice_content_gate_lookups'] = [];
}
$GLOBALS['allspice_content_gate_lookups'][$key] = $rule;
return $rule;
}
/*
* Exclusions: the locally generated membership landing page is NEVER gated (identified by
* the allspice_membership_page_id filter/setting once that feature lands - 0 = none yet),
* and developers can exclude further post ids/urls via allspice_content_gate_excluded.
*/
function allspice_content_gate_is_excluded(int $post_id, string $url_key = ''): bool {
$landing_id = (int)apply_filters('allspice_membership_page_id', (int)get_option('allspice_membership_page_id', 0));
if ($landing_id > 0 && $post_id === $landing_id) return true;
return (bool)apply_filters('allspice_content_gate_excluded', false, $post_id, $url_key);
}
/* Rule for a WordPress post/page: permalink -> url_key -> lookup. WP post ids are NEVER the
remote identity - they only resolve to the permalink locally. */
function allspice_content_gate_for_post($post_id) {
$post_id = (int)$post_id;
if ($post_id <= 0) return null;
$permalink = get_permalink($post_id);
if (!is_string($permalink) || $permalink === '') return null;
$key = allspice_content_gate_key($permalink);
if ($key === '' || allspice_content_gate_is_excluded($post_id, $key)) return null;
return allspice_content_gate_for_url_key($key);
}
/* Rule for the current main-query singular object, or null. */
function allspice_current_content_gate() {
if (!is_singular()) return null;
$post_id = (int)get_queried_object_id();
if ($post_id <= 0) return null;
return allspice_content_gate_for_post($post_id);
}